今天我們透過幾個例子來觀察,當使用純Python及Polars時,其各自是如何解決問題,進而從中了解Polars帶來了什麼便利。
本日大綱如下:
codepanda
import pandas as pd
import polars as pl
import polars.selectors as cs
from itertools import groupby
假設我們有一組列表lucky_numbers
,內含五個幸運數字:
lucky_numbers = [5, 93, 42, 55, 74]
如果想將lucky_numbers
內每一個元素加1,可以使用迴圈:
[number+1 for number in lucky_numbers]
或是使用map
:
list(map(lambda x: x+1, lucky_numbers))
皆可以得到:
[6, 94, 43, 56, 75]
如果是使用Polars來操作的話,可以使用pl.DataFrame.select()
來達成:
df_lucky_numbers = pl.DataFrame({"lucky_number": lucky_numbers})
df_lucky_numbers.select(pl.col("lucky_number").add(1))
shape: (5, 1)
┌──────────────┐
│ lucky_number │
│ --- │
│ i64 │
╞══════════════╡
│ 6 │
│ 94 │
│ 43 │
│ 56 │
│ 75 │
└──────────────┘
其中:
pl.DataFrame.select()
是一種context,代表我們想要選取括號中expr所產生的結果。pl.col("lucky_number")
是一個expr,代表我們選取了「"lucky_number"」這一列。我們於此expr後接上add(1)
,代表我們想將「"lucky_number"」這一列的每個欄位都加上1。Polars讓我們可以不用使用迴圈,而以列為中心且以expr來建構所有操作,這即是向量化操作(Vectorized operation)。
如果我們只想選出lucky_numbers
中,大於50的元素,可以這麼做:
[number for number in lucky_numbers if number > 50]
或是使用filter
:
list(filter(lambda x: x > 20, lucky_numbers))
皆可以得到:
[93, 55, 74]
如果是使用Polars來操作的話,可以使用pl.DataFrame.filter()
來達成:
df_lucky_numbers.filter(pl.col("lucky_number") > 50)
shape: (3, 1)
┌──────────────┐
│ lucky_number │
│ --- │
│ i64 │
╞══════════════╡
│ 93 │
│ 55 │
│ 74 │
└──────────────┘
其中:
pl.DataFrame.filter()
是一種context,其括號內的expr必須回傳布林值。而pl.DataFrame.filter()
可以幫助我們選擇結果為True
的行。pl.col("lucky_number")
是一個expr,代表我們選取了「"lucky_number"」這一列。我們於此expr後接上> 50
,代表我們想知道「"lucky_number"」這一列的每個欄位,其值是否大於50。如果大於50,回傳True
,反之則回傳False
。Polars讓我們可以使用
pl.DataFrame.filter()
來篩選所需行數。請留意,此時我們仍然是以列為中心來思考,只是需要將條件篩選的expr置入pl.DataFrame.filter()
中。
假設我們有一組列表names
,內有五個英文名:
names = ["May", "Jeff", "Cathy", "Jack", "David"]
如果我們想針對名字長度來作分組,可以這麼做:
groups, uniquekeys = [], []
for k, g in groupby(sorted(names, key=len), key=len):
groups.append(list(g))
uniquekeys.append(k)
print(f"{uniquekeys=}")
print(f"{groups=}")
uniquekeys=[3, 4, 5]
groups=[['May'], ['Jeff', 'Jack'], ['Cathy', 'David']]
此段程式碼節錄自Python官方說明文檔(註1)。
如果是使用Polars來操作的話,可以使用pl.DataFrame.group_by()
來達成:
df_names = pl.DataFrame({"name": names})
(
df_names.group_by(pl.col("name").str.len_bytes().alias("len"))
.agg(pl.col("name"))
.sort(pl.col("len"))
)
shape: (3, 2)
┌─────┬────────────────────┐
│ len ┆ name │
│ --- ┆ --- │
│ u32 ┆ list[str] │
╞═════╪════════════════════╡
│ 3 ┆ ["May"] │
│ 4 ┆ ["Jeff", "Jack"] │
│ 5 ┆ ["Cathy", "David"] │
└─────┴────────────────────┘
其中:
pl.DataFrame.group_by()
是一種context,其括號內的expr為分組的目標。pl.col("name").str.len_bytes().alias("len")
是一個expr,代表我們選取了「"names"」這一列。接著我們使用了str
這個accessor,來呼叫len_bytes()
,最後再利用.alias("len")
重名命名此列為「"len"」。.agg()
可以想成是pl.DataFrame.group_by()
的後續動作,其括號內的expr為該分組內元素所需進行的聚合計算。pl.col("name")
是一個expr,其置於.agg()
之內,代表我們想將符合各種條件的元素,各自集合為一個Polars的List
型態(不是Python的list
型別)。pl.DataFrame.sort()
以括號內的expr做為排序依據並以升冪型式呈現。pl.col("len")
是一個expr,代表「"len"」列。Polars讓我們可以使用
pl.DataFrame.group_by().agg()
來進行聚合計算,相比於itertools.groupby
,更加容易使用。請留意,我們仍然是以列為中心來思考,只是需要將代表聚合的expr置入pl.DataFrame.group_by().agg()
中。
假設我們將names
與lucky_numbers
列表合併為一個data
字典:
data = {"name": names, "lucky_number": lucky_numbers}
我們可以使用data["name"]
或data["lucky_number"]
來取得names
或lucky_numbers
列表。
如果是使用Polars來操作的話,除了基本的pl.col()
選取方式外,還可以使用多種selector來選取,且selector間還可以進行set operation。例如:
df = pl.DataFrame(data)
df.select(cs.by_name("name") | cs.numeric())
shape: (5, 2)
┌───────┬──────────────┐
│ name ┆ lucky_number │
│ --- ┆ --- │
│ str ┆ i64 │
╞═══════╪══════════════╡
│ May ┆ 5 │
│ Jeff ┆ 93 │
│ Cathy ┆ 42 │
│ Jack ┆ 55 │
│ David ┆ 74 │
└───────┴──────────────┘
這裡我們使用pl.DataFrame.select()
這個context來選取列。其中cs.by_name("name")
選取到了「"names"」列,而cs.numeric()
選取到「"len"」列。最後兩個selector的結果在進行|
運算後,選擇到「"names"與「"len"」兩列。
Python原生資料結構的選取方法十分便利,但Polars提供了豐富的selector,提供使用者更多彈性的選擇方法。而所有的一切,仍然是以列為中心來思考。
最後我想舉一個實際看到的例子,這是PyBites創辦人之一,Bob Belderbos,發表在LinkedIn的一則貼文。其目標是希望能計算多篇文章中,各種tag使用的次數。
Bob只用了三行即完成計算,並使用了Python的Pandas、itertools
模組及collections
模組,可以看出他對Python的熟悉程度。
import itertools
import collections
df_text = pl.DataFrame(
{
"text": [
"Tags: #Coding #ProblemSolving",
"Tags: #OpenSource #Collaboration #Efficiency",
"Tags: #ProblemSolving #Efficiency",
]
}
)
tags = df_text["text"].str.extract_all(r"#\w+").to_list()
tags_flattened = (
tag.lower() for tag in itertools.chain.from_iterable(tags)
)
most_common_tags = collections.Counter(tags_flattened)
print(most_common_tags)
Counter({'#problemsolving': 2,
'#efficiency': 2,
'#coding': 1,
'#opensource': 1,
'#collaboration': 1})
但是如果使用Polars,我們可以使用更簡潔的寫法得到答案。例如:
(
df_text.select(
pl.col("text")
.str.extract_all(r"#\w+")
.list.eval(pl.element().str.to_lowercase())
.explode()
.value_counts(sort=True)
.struct.unnest()
)
)
shape: (5, 2)
┌─────────────────┬───────┐
│ text ┆ count │
│ --- ┆ --- │
│ str ┆ u32 │
╞═════════════════╪═══════╡
│ #problemsolving ┆ 2 │
│ #efficiency ┆ 2 │
│ #coding ┆ 1 │
│ #opensource ┆ 1 │
│ #collaboration ┆ 1 │
└─────────────────┴───────┘
雖然您現在可能看不懂上述程式碼,但我們可以將其與前段程式碼進行比較如下:
.list.eval()
替代迴圈。pl.Expr.value_counts()
會返回pl.Struct
)來替代collection.Counter
。如果我們改變心態,盡量減少使用純Python來操作,而多使用Polars提供的各種功能,就能夠享受更多其所帶來的效能。
codepanda
Pandas可以使用pd.DataFrame.assign()
來新增或修改列:
lucky_numbers = [5, 93, 42, 55, 74]
df_lucky_numbers = pd.DataFrame({"lucky_number": lucky_numbers})
(
df_lucky_numbers.assign(
lucky_number=lambda df_: df_.lucky_number.add(1)
)
)
lucky_number
0 6
1 94
2 43
3 56
4 75
Pandas可以使用pd.DataFrame.query()
來篩選行:
lucky_numbers = [5, 93, 42, 55, 74]
df_lucky_numbers = pd.DataFrame({"lucky_numbers": lucky_numbers})
df_lucky_numbers.query("lucky_numbers > 50")
lucky_number
1 93
3 55
4 74
Pandas可以使用pd.DataFrame.groupby().agg()
來進行分組聚合:
names = ["May", "Jeff", "Cathy", "Jack", "David"]
df_names = pd.DataFrame({"names": names})
(
df_names.assign(len=lambda df_: df_.names.str.len())
.groupby("len")
.agg(list)
.reset_index()
)
len name
0 3 [May]
1 4 [Jeff, Jack]
2 5 [Cathy, David]
Pandas可以使用pd.DataFrame.loc
來選擇單列或多列:
names = ["May", "Jeff", "Cathy", "Jack", "David"]
lucky_numbers = [5, 93, 42, 55, 74]
data = {"names": names, "lucky_numbers": lucky_numbers}
df = pl.DataFrame(data)
df.loc[:, ["names", "lucky_numbers"]]
name lucky_number
0 May 5
1 Jeff 93
2 Cathy 42
3 Jack 55
4 David 74
註1:這邊需留意,使用itertools.groupby
時,需先進行排序,否則其預設的邏輯是當遇到不同情況時,即視為前一組別聚合完畢。舉例來說:
groups, uniquekeys = [], []
for k, g in groupby(names, key=len):
groups.append(list(g))
uniquekeys.append(k)
print(f"{uniquekeys=}")
print(f"{groups=}")
uniquekeys=[3, 4, 5, 4, 5]
groups=[['May'], ['Jeff'], ['Cathy'], ['Jack'], ['David']]
此例中的names
未先進行排序,且由於相鄰的名字剛好為不同長度,所以會產生五組結果。